package com.nulldev.util.networking.rudp;

import java.io.IOException;
import java.math.BigInteger;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetAddress;
import java.net.SocketException;
import java.net.UnknownHostException;
import java.nio.ByteBuffer;
import java.util.Arrays;
import java.util.Iterator;
import java.util.List;
import java.util.UUID;

import com.nulldev.util.VariableAPI.RandomUtil;
import com.nulldev.util.data.Arrays.arrays.FastLists;
import com.nulldev.util.logging.Logger;
import com.nulldev.util.logging.Logger.Level;
import com.nulldev.util.logging.LoggerFactory;
import com.nulldev.util.networking.npacket4.nPacket;
import com.nulldev.util.networking.npacket4.micropacket.nMicroPacket;
import com.nulldev.util.networking.rudp.util.SendDataQue;
import com.nulldev.util.networking.rudp.util.SerializationUtils;

public class Client {
	private static final Logger LOG = LoggerFactory.getLogger();

	// The name of the protocol version, to sort out incorrect package
	private volatile int protocolVersion = "rUDP/1.0".hashCode();

	// Max size that can be sent in one package
	private final int bufferSize = 512;

	/**
	 * This should be same as the server 4 byte protocol 1 byte type of package 4
	 * byte sequence ID
	 */
	private final static int byteBufferSize = 9;

	// How long time in milliseconds it must pass before we try to resend data
	private volatile long millisecondsBetweenResend = 500;

	// How long time in milliseconds it must pass before a disconnect
	private volatile long millisecondToTimeout = 20000;

	// How long it should wait between every check for disconnect and resend data.
	// This should be lower or same as millisecondsBetweenResend
	private volatile long millisecondsToRecheckConnection = 20;

	// Default host address when start a new client
	private final static String defaultHostAddress = "127.0.0.1";

	// Default port when start a new client
	private final static int defaultPort = 7854;

	private volatile Connection connection;
	private volatile DatagramSocket sock = null;
	private volatile DatagramPacket dp;
	private volatile byte[] sendData;
	private volatile boolean running = true;
	private volatile ClientThread clientThread;
	private volatile HandleConnection handleConnection = new HandleConnection();
	private volatile NetworkPackage sendingPackage;
	private final List<DataReceivedListener> dataReceivedListeners = FastLists.list();
	private volatile ByteBuffer resendByteBuffer;
	private volatile ByteBuffer sendByteBuffer;

	// If we want to simulate delay when we try internaly
	private volatile boolean simulateDelay = false;

	// How much delay should we simulate
	// Default: min: 100, max: 500
	private volatile long simulateDelayTimeMin = 0, simulateDelayTimeMax = 0, simulatedDelay = 0;

	/**
	 * Create new client
	 */
	public Client() {
		try {
			sock = new DatagramSocket();
			if (clientThread == null || !clientThread.isAlive()) {
				connection = new Connection(InetAddress.getByName(defaultHostAddress), defaultPort);
			}
		} catch (SocketException e) {
			LOG.log(Level.SEVERE, "Error create client", e);
		} catch (UnknownHostException e) {
			LOG.log(Level.SEVERE, "Error unknown host ", e);
		}
	}

	public static Logger getLog() {
		return LOG;
	}

	/**
	 * Create new client and set port and ip to server
	 * 
	 * @param port
	 * @param serverIP
	 */
	public Client(int port, String serverIP) {
		try {
			sock = new DatagramSocket();
			if (clientThread == null || !clientThread.isAlive()) {
				connection = new Connection(InetAddress.getByName(serverIP), port);
			}
		} catch (SocketException e) {
			LOG.log(Level.SEVERE, "Error create client", e);
		} catch (UnknownHostException e) {
			LOG.log(Level.SEVERE, "Error create client", e);
		}
	}

	/**
	 * Create new client and set port, ip to server and protocol version. Must be
	 * same on client and server.
	 * 
	 * @param port
	 * @param serverIP
	 * @param protocolVersionName
	 */
	public Client(int port, String serverIP, String protocolVersionName) {
		this.protocolVersion = protocolVersionName.hashCode();
		try {
			sock = new DatagramSocket();
			if (clientThread == null || !clientThread.isAlive()) {
				connection = new Connection(InetAddress.getByName(serverIP), port);
			}
		} catch (SocketException e) {
			LOG.log(Level.SEVERE, "Error create client", e);
		} catch (UnknownHostException e) {
			LOG.log(Level.SEVERE, "Error create client", e);
		}
	}

	/**
	 * Register client listener
	 * 
	 * @param dataReceivedListener
	 */
	public void registerClientListener(DataReceivedListener dataReceivedListener) {
		dataReceivedListeners.add(dataReceivedListener);
	}

	/**
	 * Change port and ip the client should connect to
	 * 
	 * @param port
	 * @param serverIP
	 */
	public void setPortAndIp(int port, String serverIP) {
		try {
			if (clientThread == null || !clientThread.isAlive()) {
				connection = new Connection(InetAddress.getByName(serverIP), port);
			}
		} catch (UnknownHostException e) {
			LOG.log(Level.SEVERE, "Error change port and IP", e);
		}
	}

	/**
	 * Start client, if no port and ip have been specified it will use localhost
	 * address and 7854 as port
	 */
	public void startClient() {
		if (connection == null) {
			try {
				connection = new Connection(InetAddress.getByName(defaultHostAddress), defaultPort);
				for (DataReceivedListener dataReceivedListener : dataReceivedListeners) {
					dataReceivedListener.connected(connection);
				}
			} catch (UnknownHostException e) {
				LOG.log(Level.SEVERE, "Error connect to server", e);
			}
		} else if (connection != null) {
			for (DataReceivedListener dataReceivedListener : dataReceivedListeners) {
				dataReceivedListener.connected(connection);
			}
		}
		connection.updateTime();
		running = true;
		clientThread = new ClientThread();
		clientThread.start();
		handleConnection.start();
		connection.addToSendQue(new byte[0], NetworkSendType.INITSIGNAL);
	}

	/**
	 * @return connection for client
	 */
	public Connection getConnection() {
		return connection;
	}

	/**
	 * OBS! Must be same on both client and server
	 * 
	 * @param protocolVersion
	 */
	public void setProtocolVersion(int protocolVersion) {
		this.protocolVersion = protocolVersion;
	}

	/**
	 * How long should the client wait for ack before it resend a message
	 * 
	 * @param millisecondsBetweenResend
	 */
	public void setMillisecondsBetweenResend(long millisecondsBetweenResend) {
		this.millisecondsBetweenResend = millisecondsBetweenResend;
	}

	/**
	 * How long it should wait before it disconnect from server if no packages are
	 * received
	 * 
	 * @param millisecondToTimeout
	 */
	public void setMillisecondToTimeout(long millisecondToTimeout) {
		this.millisecondToTimeout = millisecondToTimeout;
	}

	/**
	 * How long it should wait between every check for disconnect and resend data
	 * Default are 20 milliseconds
	 * 
	 * @param millisecondsToRecheckConnection
	 */
	public void setMillisecondsToRecheckConnection(long millisecondsToRecheckConnection) {
		this.millisecondsToRecheckConnection = millisecondsToRecheckConnection;
	}

	/**
	 * Switch simulated delay on and of
	 * 
	 * @param simulateDelay
	 */
	public void setSimulateDelay(boolean simulateDelay) {
		this.simulateDelay = simulateDelay;
		simulatedDelay = RandomUtil.randLong(simulateDelayTimeMin, simulateDelayTimeMax);
	}

	/**
	 * Set how much simulated delay should be used
	 * 
	 * @param simulateDelayTimeMin
	 * @param simulateDelayTimeMax
	 */
	public void setSimulateDelayTime(long simulateDelayTimeMin, long simulateDelayTimeMax) {
		this.simulateDelayTimeMin = simulateDelayTimeMin;
		this.simulateDelayTimeMax = simulateDelayTimeMax;
	}

	/**
	 * Send a ping to server to check if it is there
	 */
	public void sendPing() {
		connection.addToSendQue(new byte[0], NetworkSendType.PING);
	}

	/**
	 * Send an unreliable object to server. Just a simpler way then send a byte[]
	 * 
	 * @param sendObjct
	 */
	public <T> void sendUnreliableDataObject(T sendObjct) {
		sendUnreliableData(SerializationUtils.getInstance().serialize(sendObjct));
	}

	/**
	 * Use this if you don't care if the message get to the server
	 * 
	 * @param sendData
	 */
	public void sendUnreliableData(byte[] sendData) {
		connection.addToSendQue(sendData, NetworkSendType.UNRELIABLE_DATA);
	}

	public void sendUnreliablePacket(final nPacket packet) {
		this.sendUnreliableData(packet.makePacket());
	}

	public void sendUnreliablePacket(final nMicroPacket packet) {
		this.sendUnreliableData(packet.makePacket());
	}

	/**
	 * Send a reliable object to server. Just a simpler way then send a byte[]
	 * 
	 * @param sendObject
	 */
	public <T> void sendReliableDataObject(T sendObject) {
		sendReliableData(SerializationUtils.getInstance().serialize(sendObject));
	}

	/**
	 * Use this if it is rely important the message get to the server
	 * 
	 * @param sendData
	 */
	public void sendReliableData(byte[] sendData) {
		connection.addToSendQue(sendData, NetworkSendType.RELIABLE_DATA);
	}

	public void sendReliablePacket(final nPacket packet) {
		this.sendReliableData(packet.makePacket());
	}

	public void sendReliablePacket(final nMicroPacket packet) {
		this.sendReliableData(packet.makePacket());
	}

	/**
	 * You can trigger the resend method just to tell it to send messages that have
	 * reached the max limits of a message
	 */
	public synchronized void resendData() {
		long currentTime = System.currentTimeMillis();
		long smoothTime = connection.getSmoothRoundTripTime();
		for (NetworkPackage networkPackage : connection.getNetworkPackageArrayMap().values()) {
			if ((currentTime - networkPackage.getSentTime() - Math.max(millisecondsBetweenResend, smoothTime * networkPackage.getResent())) > 0) {
				// System.out.println("RESEND! : " + networkPackage.getSequenceNumber());
				try {
					networkPackage.resendData(networkPackage.getSequenceNumber());
					if (networkPackage.getNetworkSendType() == NetworkSendType.RELIABLE_SPLIT_DATA) {
						resendByteBuffer = ByteBuffer.allocate(byteBufferSize + 4 + networkPackage.getDataSent().length);
						resendByteBuffer.putInt(protocolVersion).put((byte) networkPackage.getNetworkSendType().getTypeCode())
								.putInt(networkPackage.getSequenceNumber()).putInt(networkPackage.getSplitSequenceNumber()).put(networkPackage.getDataSent());
					} else {
						resendByteBuffer = ByteBuffer.allocate(byteBufferSize + networkPackage.getDataSent().length);
						resendByteBuffer.putInt(protocolVersion).put((byte) networkPackage.getNetworkSendType().getTypeCode())
								.putInt(networkPackage.getSequenceNumber()).put(networkPackage.getDataSent());
					}

					sendData = resendByteBuffer.array();
					dp = new DatagramPacket(sendData, sendData.length, connection.getAddress(), connection.getPort());
					sock.send(dp);

				} catch (IOException e) {
					LOG.log(Level.SEVERE, "Error send data", e);
				}
			}
		}
	}

	private synchronized void sendFromQue() {
		if (!running || !clientThread.isAlive() || sock == null) {
			return;
		}
		if (connection.getNextSendQueData()) {
			Iterator<SendDataQue> iter = connection.getSendDataQueList().iterator();
			while (iter.hasNext()) {
				SendDataQue sendDataQue = iter.next();
				if (simulateDelay) {
					if ((System.currentTimeMillis() - sendDataQue.getAddedTime() - simulatedDelay) < 0) {
						continue;
					} else {
						simulatedDelay = RandomUtil.randLong(simulateDelayTimeMin, simulateDelayTimeMax);
					}
				}
				sendData(sendDataQue.getaByte(), sendDataQue.getNetworkSendType());

				connection.getSendDataQuePool().giveBack(sendDataQue);
				iter.remove();

			}
			connection.setLastSentMessageTime(System.currentTimeMillis());
		}
	}

	/**
	 * Check if the server have been disconnected
	 */
	public synchronized void disconnectInactiveServer() {
		if (clientThread != null && clientThread.isAlive()) {
			long currentTime = System.currentTimeMillis();
			if (currentTime > millisecondToTimeout + connection.getLastRecived()) {
				disconnectedClients(connection.getConnectionID());
				killConnection();
			}
		}
	}

	public synchronized void removeInactiveSplitMessages() {
		if (clientThread != null && clientThread.isAlive()) {
			connection.removeSplitMessages();
		}
	}

	/**
	 * If you want to terminate the connection between client and server
	 */
	public void killConnection() {
		if (clientThread.isAlive()) {
			running = false;
			sock.close();
			clientThread.interrupt();
			handleConnection.interrupt();
		}
	}

	private void recivedMessage(Message message, final Connection connection) {
		for (DataReceivedListener dataReceivedListener : dataReceivedListeners) {
			dataReceivedListener.receivedMessage(message, connection);
		}
	}

	private void disconnectedClients(UUID clientId) {
		for (DataReceivedListener dataReceivedListener : dataReceivedListeners) {
			dataReceivedListener.disconnected(clientId);
		}
	}

	private synchronized void sendData(byte[] data, NetworkSendType sendType) {
		try {
			LOG.trace("DataSent: " + data.length + " SendType: " + sendType.name());
			if (data.length + byteBufferSize <= bufferSize) {
				sendByteBuffer = ByteBuffer.allocate(byteBufferSize + data.length);
				sendingPackage = connection.getNetworkPackage(data, sendType);
				sendByteBuffer.putInt(protocolVersion).put((byte) sendType.getTypeCode()).putInt(sendingPackage.getSequenceNumber()).put(data);
				sendData = sendByteBuffer.array();
				dp = new DatagramPacket(sendData, sendData.length, connection.getAddress(), connection.getPort());
				sock.send(dp);
				connection.getNetworkPackagePool().giveBack(sendingPackage);
			} else {
				int len = 0;
				byte[] dataToSend;
				int splitIdSize = 4;
				int splitId = connection.getGlobalSplitSequenceNumber();
				while (true) {
					if (data.length - len > bufferSize - byteBufferSize - splitIdSize - 4) {
						dataToSend = Arrays.copyOfRange(data, len, bufferSize - byteBufferSize - splitIdSize - 4 + len);
						sendByteBuffer = ByteBuffer.allocate(bufferSize - 4);
						sendByteBuffer.putInt(protocolVersion);
						if (sendType == NetworkSendType.RELIABLE_DATA || sendType == NetworkSendType.RELIABLE_SPLIT_DATA) {
							sendByteBuffer.put((byte) NetworkSendType.RELIABLE_SPLIT_DATA.getTypeCode());
							sendingPackage = connection.getNetworkPackage(dataToSend, NetworkSendType.RELIABLE_SPLIT_DATA);
						} else {
							sendByteBuffer.put((byte) NetworkSendType.UNRELIABLE_SPLIT_DATA.getTypeCode());
							sendingPackage = connection.getNetworkPackage(dataToSend, NetworkSendType.UNRELIABLE_SPLIT_DATA);
						}
						sendByteBuffer.putInt(sendingPackage.getSequenceNumber()).putInt(splitId).put(dataToSend);
						len += bufferSize - byteBufferSize - splitIdSize - 4;
						sendData = sendByteBuffer.array();
						dp = new DatagramPacket(sendData, sendData.length, connection.getAddress(), connection.getPort());
						sock.send(dp);
						connection.getNetworkPackagePool().giveBack(sendingPackage);
					} else {
						dataToSend = Arrays.copyOfRange(data, len, data.length);
						ByteBuffer byteBuffer = ByteBuffer.allocate(byteBufferSize + splitIdSize + 4 + dataToSend.length);
						byteBuffer.putInt(protocolVersion);
						if (sendType == NetworkSendType.RELIABLE_DATA || sendType == NetworkSendType.RELIABLE_SPLIT_DATA) {
							byteBuffer.put((byte) NetworkSendType.RELIABLE_SPLIT_DATA.getTypeCode());
							sendingPackage = connection.getNetworkPackage(dataToSend, NetworkSendType.RELIABLE_SPLIT_DATA);
						} else {
							byteBuffer.put((byte) NetworkSendType.UNRELIABLE_SPLIT_DATA.getTypeCode());
							sendingPackage = connection.getNetworkPackage(dataToSend, NetworkSendType.UNRELIABLE_SPLIT_DATA);
						}
						byteBuffer.putInt(sendingPackage.getSequenceNumber()).putInt(splitId).put(dataToSend).putInt(Arrays.hashCode(data));
						sendData = byteBuffer.array();
						dp = new DatagramPacket(sendData, sendData.length, connection.getAddress(), connection.getPort());
						sock.send(dp);
						connection.getNetworkPackagePool().giveBack(sendingPackage);
						break;
					}
				}
			}
			if (sendingPackage != null) {
				LOG.trace("SendData: " + sendingPackage.getSequenceNumber() + " type: " + sendingPackage.getNetworkSendType().name());
			} else {
				LOG.log(Level.SEVERE, "ERROR!!!");
			}
		} catch (IOException e) {
			LOG.log(Level.SEVERE, "Error send data", e);
		}
	}

	private synchronized void sendAck(int sequenceNumberToAck) {
		try {
			connection.setReceivedPackageStack(sequenceNumberToAck);
			ByteBuffer byteBuffer = ByteBuffer.allocate(byteBufferSize + 4);
			sendingPackage = connection.getAckPackage();
			byteBuffer.putInt(protocolVersion).put((byte) NetworkSendType.ACK.getTypeCode()).putInt(sendingPackage.getSequenceNumber())
					.putInt(sequenceNumberToAck);
			sendData = byteBuffer.array();
			dp = new DatagramPacket(sendData, sendData.length, connection.getAddress(), connection.getPort());
			sock.send(dp);
			connection.getNetworkPackagePool().giveBack(sendingPackage);
		} catch (IOException e) {
			LOG.log(Level.SEVERE, "Error send data", e);
		}
	}

	private class ClientThread extends Thread {

		private final ByteBuffer byteBuffer = ByteBuffer.allocate(bufferSize);
		private NetworkPackage tempPackage;
		private final Message message = new Message();

		@Override
		public void run() {
			final byte[] buffer = new byte[bufferSize];
			final DatagramPacket incoming = new DatagramPacket(buffer, buffer.length);

			while (running) {
				try {
					sock.receive(incoming);

					final byte[] data = incoming.getData();
					byteBuffer.clear();
					byteBuffer.limit(incoming.getLength());
					byteBuffer.put(incoming.getData(), 0, incoming.getLength());
					byteBuffer.flip();

					if (byteBuffer.getInt(0) != protocolVersion) {
						continue;
					}
					// If we get a wrong network type we ignore everything else
					if (byteBuffer.get(4) == NetworkSendType.ERROR.getTypeCode()) {
						LOG.error("ERROR network type");
						continue;
					}
					// Anyone should be able to ping the connection if they have the correct
					// protocol
					if (byteBuffer.get(4) == NetworkSendType.PING.getTypeCode()) {
						sendAck(byteBuffer.getInt(5));
						connection.updateTime();
						continue;
					}
					// If server use que the client get where in the que it is
					if (byteBuffer.get(4) == NetworkSendType.QUENUMBER.getTypeCode()) {
						sendAck(byteBuffer.getInt(5));
						connection.updateTime();
						message.setData(Arrays.copyOfRange(data, byteBufferSize, incoming.getLength()));
						message.setNetworkSendType(NetworkSendType.fromByteValue(byteBuffer.get(4)));
						recivedMessage(message, connection);
						continue;
					}

					// Verify if we have received ack from before otherwise remove so we don't send
					// more request
					if (byteBuffer.get(4) == NetworkSendType.ACK.getTypeCode()) {
						if (incoming.getLength() == 13) {
							verifyAck(byteBuffer.getInt(9));
						}
						continue;
					}

					// Check if we have already received data then send another ack and don't do
					// anything else
					if (connection.isReceivedPackageStack(byteBuffer.getInt(5))) {
						sendAck(byteBuffer.getInt(5));
						continue;
					}

					message.setNetworkSendType(NetworkSendType.fromByteValue(byteBuffer.get(4)));

					if (message.getNetworkSendType() == NetworkSendType.RELIABLE_SPLIT_DATA
							|| message.getNetworkSendType() == NetworkSendType.UNRELIABLE_SPLIT_DATA) {
						message.setData(Arrays.copyOfRange(data, byteBufferSize + 4, incoming.getLength()));
						message.setSequenceID(byteBuffer.getInt(5));
						message.setSplitMessageID(new BigInteger(Arrays.copyOfRange(data, byteBufferSize, byteBufferSize + 4)).intValue());
						handleSplitMessages(message);
					} else {
						message.setData(Arrays.copyOfRange(data, byteBufferSize, incoming.getLength()));
						message.setSequenceID(byteBuffer.getInt(5));

						if (message.getNetworkSendType() == NetworkSendType.RELIABLE_DATA) {
							sendAck(byteBuffer.getInt(5));
						}
						recivedMessage(message, connection);
						connection.updateTime();
					}

				} catch (IOException e) {
					LOG.log(Level.SEVERE, "Error receive data", e);
					running = false;
				}
			}
		}

		private void handleSplitMessages(final Message message) {
			// If we have started to receive part of split message
			final byte[] data = connection.setSplitMessageData(message.getSplitMessageID(), message.getSequenceID(), message.getData());
			if (data.length > 0) {
				message.setData(data);
				recivedMessage(message, connection);
			}
			if (message.getNetworkSendType() == NetworkSendType.RELIABLE_SPLIT_DATA) {
				sendAck(message.getSequenceID());
			}
			connection.updateTime();
		}

		private void verifyAck(final int sequenceNumberWasAcked) {
			tempPackage = connection.getNetworkPackageArrayMap().remove(sequenceNumberWasAcked);
			if (tempPackage != null) {
				final long roundTripTime = System.currentTimeMillis() - tempPackage.getSentTime();
				connection.getRoundTripTimes().push(roundTripTime);
				// System.out.println("smoothTime: " + connection.getSmoothRoundTripTime());
				if (tempPackage.getNetworkSendType() == NetworkSendType.PING) {
					connection.setLastPingTime(roundTripTime);
				}
				connection.updateTime();
			}
			tempPackage = null;
		}
	}

	private class HandleConnection extends Thread {

		@Override
		public void run() {
			while (running) {
				resendData();
				sendFromQue();
				disconnectInactiveServer();
				removeInactiveSplitMessages();
				try {
					Thread.sleep(millisecondsToRecheckConnection);
				} catch (InterruptedException e) {
					// Ignore error
				}
			}
		}
	}
}
